// The five files
//   Option.java
//   OptionGroup.java
//   Options.java
//   Unpublicized.java
//   OptionsDoclet.java
// together comprise the implementation of command-line processing.

package plume;

import java.io.*;
import java.util.*;
import java.lang.reflect.*;
import com.sun.javadoc.*;

import java.lang.Class;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.StringEscapeUtils;

/**
 * Generates HTML documentation of command-line options.
 * <p>
 *
 * <b>Usage</b> <p>
 * This doclet is typically invoked with:
 * <pre>javadoc -quiet -doclet plume.OptionsDoclet [doclet options] [java files]</pre>
 * <p>
 *
 * <b>Doclet Options</b> <p>
 * The following doclet options are supported:
 * <ul>
 * <li> <b>-docfile</b> <i>file</i> When specified, the output of this doclet
 * is the result of replacing everything between the two lines
 * <pre>&lt;!-- start options doc (DO NOT EDIT BY HAND) --&gt;</pre>
 * and
 * <pre>&lt;!-- end options doc --&gt;</pre>
 * in <i>file</i> with the options documentation.  This can be used for
 * inserting option documentation into an existing manual.  The existing
 * docfile is not modified; output goes to the <code>-outfile</code>
 * argument, or to standard out.
 *
 * <li> <b>-outfile</b> <i>file</i> The destination for the output (the default
 * is standard out).  If both <code>-outfile</code> and <code>-docfile</code>
 * are specified, they must be different.
 *
 * <li> <b>-i</b> Specifies that the docfile should be edited in-place.  This
 * option can only be used if the <code>-docfile</code> option is used, and
 * may not be used at the same time as the <code>-outfile</code> option.
 *
 * <li> <b>-format</b> <i>format</i> This option sets the output format of this
 * doclet.  Currently, the following values for <i>format</i> are supported:
 * <ul>
 *   <li> <b>javadoc</b> When this format is specified, the output of this
 *   doclet is formatted as a Javadoc comment.  This is useful for including
 *   option documentation inside Java source code.  When this format is used
 *   with the <code>-docfile</code> option, the generated documentation is
 *   inserted between the lines
 *   <pre>* &lt;!-- start options doc (DO NOT EDIT BY HAND) --&gt;</pre>
 *   and
 *   <pre>* &lt;!-- end options doc --&gt;</pre>
 *   using the same indentation.  Inline <code>@link</code> and
 *   <code>@see</code> tags in the Javadoc input are left untouched.
 *
 *   <li> <b>html</b> This format outputs HTML for general purpose use, meaning
 *   inline <code>@link</code> and <code>@see</code> tags in the Javadoc input
 *   are suitably replaced.  This is the default output format and does not
 *   need to be specified explicitly.  
 * </ul>
 *
 * <li> <b>-classdoc</b> When specified, the output of this doclet includes the
 * class documentation of the first class specified on the command-line.
 *
 * <li> <b>-singledash</b> When specified, <code>use_single_dash(true)</code> is
 * called on the underlying instance of Options used to generate documentation.
 * See {@link plume.Options#use_single_dash(boolean)}.
 * </ul>
 * <p>
 *
 * <b>Examples</b> <p>
 * To update the Javarifier HTML manual with option documentation run:
 * <pre>javadoc -quiet -doclet plume.OptionsDoclet -i -docfile javarifier.html src/javarifier/Main.java</pre>
 * <p>
 *
 * To update the class Javadoc for plume.Lookup with option documentation run:
 * <pre>javadoc -quiet -doclet plume.OptionsDoclet -i -docfile Lookup.java -format javadoc Lookup.java</pre>
 * <p>
 *
 * <b>Requirements</b> <p>
 * Classes passed to OptionsDoclet that have <code>@Option</code> annotations on
 * non-static fields should have a nullary (no-argument) constructor.  The
 * nullary constructor may be private or public.  This is required because an
 * object instance is needed to get the default value of a non-static field.  It
 * is cleaner to require a nullary constructor instead of trying to guess
 * arguments to pass to another constructor. <p>
 *
 * <b>Hiding default value strings</b> <p>
 * By default, the documentation generated by OptionsDoclet includes a default
 * value string for each option in square brackets after the option's
 * description, similar to the usage messages generated by {@link
 * plume.Options#usage(String...)}.  The {@link plume.Option#noDocDefault}
 * field in the <code>@Option</code> annotation can be set to <code>true</code>
 * to omit the default value string from the generated documentation for that
 * option. <p>
 *
 * Omitting the generated default value string is useful for options that have
 * system dependent defaults.  Such options are not an issue for usage messages
 * that are generated at runtime.  However, system dependent defaults do pose
 * a problem for static documentation, which is rarely regenerated and meant to
 * apply to all users.  Consider the following <code>@Option</code>-annotated
 * field:
 * <pre>
 * &#64;Option(value="&lt;timezone&gt; Set the time zone")
 * public static String timezone = TimeZone.getDefault().getID();</pre>
 * The default value for <code>timezone</code> depends on the system's timezone
 * setting.  HTML documentation of this option generated in Chicago would not
 * apply to a user in New York.  To work around this problem, the
 * default value should be hidden; instead the Javadoc for this field
 * should indicate a special default as follows.
 * <pre>
 * &#47;**
 *  * &lt;other stuff...&gt;  This option defaults to the system timezone.
 *  *&#47;
 * &#64;Option(value="&lt;timezone&gt; Set the timezone", noDocDefault=true)
 * public static String timezone = TimeZone.getDefault().getID();</pre>
 * This keeps the documentation system-agnostic. <p>
 *
 * <b>Caveats</b> <p>
 * The generated HTML documentation includes unpublicized option groups but not
 * <code>@Unpublicized</code> options.  Option groups which contain only
 * <code>@Unpublicized</code> options are not included in the output at all. <p>
 *
 * <b>Troubleshooting</b> <p>
 * If you get an error such as <tt>ARGH! @Option</tt>, then you are using a
 * buggy version of gjdoc, the GNU Classpath implementation of Javadoc.
 * To avoid the problem, upgrade or use a different Javadoc implementation. <p>
 *
 * @see plume.Option
 * @see plume.Options
 * @see plume.OptionGroup
 * @see plume.Unpublicized
 */

// This doesn't itself use plume.Options for its command-line option
// processing because a Doclet is required to implement the optionLength
// and validOptions methods.
public class OptionsDoclet {

  @SuppressWarnings("nullness") // line.separator property always exists
  private static String eol = System.getProperty("line.separator");

  private static String usage = "Provided by Options doclet:\n" +
    "-docfile <file>        Specify file into which options documentation is inserted\n" +
    "-outfile <file>        Specify destination for resulting output\n" +
    "-i                     Edit the docfile in-place\n" +
    "-format javadoc        Format output as a Javadoc comment\n" +
    "-classdoc              Include 'main' class documentation in output\n" +
    "-singledash            Use single dashes for long options (see plume.Options)\n" +
    "See the OptionsDoclet documentation for more details.";

  private static String list_help = "<tt>[+]</tt> marked option can be specified multiple times"; 


  private String startDelim = "<!-- start options doc (DO NOT EDIT BY HAND) -->";
  private String endDelim = "<!-- end options doc -->"; 

  private /*@Nullable*/ File docFile = null;
  private /*@Nullable*/ File outFile = null;
  // If true, then docFile is non-null
  private boolean inPlace = false;
  private boolean formatJavadoc = false;
  private boolean includeClassDoc = false;

  private RootDoc root;
  private Options options;

  public OptionsDoclet(RootDoc root, Options options) {
    this.root = root;
    this.options = options;
  }

  // Doclet-specific methods

  /**
   * Entry point for the doclet.
   */
  public static boolean start(RootDoc root) {
    List<Object> objs = new ArrayList<Object>();
    for (ClassDoc doc : root.specifiedClasses()) {
      // TODO: Class.forName() expects a binary name but doc.qualifiedName()
      // returns a fully qualified name.  I do not know a good way to convert
      // between these two name formats.  For now, we simply ignore inner
      // classes.  This limitation can be removed when we figure out a better
      // way to go from ClassDoc to Class<?>.
      if (doc.containingClass() != null)
        continue;

      Class<?> clazz;
      try {
        clazz = Class.forName(doc.qualifiedName(), true, Thread.currentThread().getContextClassLoader());
      } catch (ClassNotFoundException e) {
        e.printStackTrace();
        return false;
      }

      if (needsInstantiation(clazz)) {
        try {
          Constructor<?> c = clazz.getDeclaredConstructor();
          c.setAccessible(true);
          objs.add(c.newInstance());
        } catch (Exception e) {
          e.printStackTrace();
          return false;
        }
      } else {
        objs.add(clazz);
      }
    }

    Object[] objarray = objs.toArray();
    Options options = new Options(objarray);
    if (options.getOptions().size() < 1) {
      System.out.println("Error: no @Option-annotated fields found");
      return false;
    }

    OptionsDoclet o = new OptionsDoclet(root, options);
    o.setOptions(root.options());
    o.processJavadoc();
    try {
      o.write();
    } catch (Exception e) {
      e.printStackTrace();
      return false;
    }

    return true;
  }

  /**
   * Returns the number of tokens corresponding to a command-line argument of
   * this doclet or 0 if the argument is unrecognized.  This method is
   * automatically invoked.
   *
   * @see <a href="http://java.sun.com/javase/6/docs/technotes/guides/javadoc/doclet/overview.html">Doclet overview</a>
   */
  public static int optionLength(String option) {
    if (option.equals("-help")) {
      System.out.println(usage);
      return 1;
    }
    if (option.equals("-i") ||
        option.equals("-classdoc") ||
        option.equals("-singledash")) {
      return 1;
    }
    if (option.equals("-docfile") ||
        option.equals("-outfile") ||
        option.equals("-format")) {
      return 2;
    }
    return 0;
  }

  /**
   * Tests the validity of command-line arguments passed to this doclet.
   * Returns true if the option usage is valid, and false otherwise.  This
   * method is automatically invoked.
   *
   * @see <a href="http://java.sun.com/javase/6/docs/technotes/guides/javadoc/doclet/overview.html">Doclet overview</a>
   */
  public static boolean validOptions(String options[][],
                                     DocErrorReporter reporter) {
    boolean hasDocFile = false;
    boolean hasOutFile = false;
    boolean hasFormat = false;
    boolean inPlace = false;
    String docFile = null;
    String outFile = null;
    for (int oi = 0; oi < options.length; oi++) {
      String[] os = options[oi];
      String opt = os[0].toLowerCase();
      if (opt.equals("-docfile")) {
        if (hasDocFile) {
          reporter.printError("-docfile option specified twice");
          return false;
        }
        File f = new File(os[1]);
        if (!f.exists()) {
          reporter.printError("file not found: " + os[1]);
          return false;
        }
        docFile = os[1];
        hasDocFile = true;
      }
      if (opt.equals("-outfile")) {
        if (hasOutFile) {
          reporter.printError("-outfile option specified twice");
          return false;
        }
        if (inPlace) {
          reporter.printError("-i and -outfile can not be used at the same time");
          return false;
        }
        outFile = os[1];
        hasOutFile = true;
      }
      if (opt.equals("-i")) {
        if (hasOutFile) {
          reporter.printError("-i and -outfile can not be used at the same time");
          return false;
        }
        inPlace = true;
      }
      if (opt.equals("-format")) {
        if (hasFormat) {
          reporter.printError("-format option specified twice");
          return false;
        }
        if (!os[1].equals("javadoc") && !os[1].equals("html")) {
          reporter.printError("unrecognized output format: " + os[1]);
          return false;
        }
        hasFormat = true;
      }
    }
    if (docFile != null && outFile != null && outFile.equals(docFile)) {
      reporter.printError("docfile must be different from outfile");
      return false;
    }
    if (inPlace && docFile == null) {
      reporter.printError("-i supplied but -docfile was not");
      return false;
    }
    return true;
  }

  /**
   * Set the options for this class based on command-line arguments given by
   * RootDoc.options().
   */
  public void setOptions(String[][] options) {
    for (int oi = 0; oi < options.length; oi++) {
      String[] os = options[oi];
      String opt = os[0].toLowerCase();
      if (opt.equals("-docfile")) {
        this.docFile = new File(os[1]);
      } else if (opt.equals("-outfile")) {
        this.outFile = new File(os[1]);
      } else if (opt.equals("-i")) {
        this.inPlace = true;
      } else if (opt.equals("-format")) {
        if (os[1].equals("javadoc"))
          setFormatJavadoc(true);
      } else if (opt.equals("-classdoc")) {
        this.includeClassDoc = true;
      } else if (opt.equals("-singledash")) {
          setUseSingleDash(true);
      }
    }
  }

  /**
   * Determine if a class needs to be instantiated in order to work properly
   * with {@link Options}. 
   */
  private static boolean needsInstantiation(Class<?> clazz) {
    for (Field f : clazz.getDeclaredFields()) {
      if (f.isAnnotationPresent(Option.class) &&
          !Modifier.isStatic(f.getModifiers()))
        return true;
    }
    return false;
  }

  // File IO methods

  /**
   * Write the output of this doclet to the correct file.
   */
  public void write() throws Exception {
    PrintWriter out;
    String output = output();

    if (outFile != null) {
      out = new PrintWriter(new BufferedWriter(new FileWriter(outFile)));
    } else if (inPlace) {
      assert docFile != null : "@SuppressWarnings(nullness): dependent: docFile is non-null if inPlace is true";
      out = new PrintWriter(new BufferedWriter(new FileWriter(docFile)));
    } else {
      out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(System.out)));
    }

    out.println(output);
    out.flush();
    out.close();
  }

  /**
   * Get the final output of this doclet.  The string returned by this method
   * is the output seen by the user.
   */
  public String output() throws Exception {
    if (docFile == null) {
      if (formatJavadoc)
        return optionsToJavadoc(0);
      else
        return optionsToHtml();
    }

    return newDocFileText();
  }

  /**
   * Get the result of inserting the options documentation into the docfile.
   */
  /*@NonNullOnEntry("docFile")*/
  private String newDocFileText() throws Exception {
    StringBuilderDelimited b = new StringBuilderDelimited(eol);
    BufferedReader doc = new BufferedReader(new FileReader(docFile));
    String docline;
    boolean replacing = false;
    boolean replaced_once = false;

    while ((docline = doc.readLine()) != null) {
      if (replacing) {
        if (docline.trim().equals(endDelim))
          replacing = false;
        else
          continue;
      }

      b.append(docline);

      if (!replaced_once && docline.trim().equals(startDelim)) {
        if (formatJavadoc)
          b.append(optionsToJavadoc(docline.indexOf('*')));
        else
          b.append(optionsToHtml());
        replaced_once = true;
        replacing = true;
      }
    }

    doc.close();
    return b.toString();
  }

  // HTML and Javadoc processing methods

  /**
   * Process each option and add in the Javadoc info.
   */
  public void processJavadoc() {
    for (Options.OptionInfo oi : options.getOptions()) {
      ClassDoc opt_doc = root.classNamed(oi.get_declaring_class().getName());
      if (opt_doc != null) {
        String nameWithUnderscores = oi.long_name.replace('-', '_');
        for (FieldDoc fd : opt_doc.fields()) {
          if (fd.name().equals (nameWithUnderscores)) {
            // If Javadoc for field is unavailable, then use the @Option
            // description in the documentation.
            if (fd.getRawCommentText().length() == 0) {
              // Input is a string rather than a Javadoc (HTML) comment so we
              // must escape it.
              oi.jdoc = StringEscapeUtils.escapeHtml(oi.description);
            } else if (formatJavadoc) {
              oi.jdoc = fd.commentText();
            } else {
              oi.jdoc = javadocToHtml(fd);
            }
            break;
          }
        }
      }
      if (oi.base_type.isEnum()) {
        processEnumJavadoc(oi);
      }
    }
  }

  /**
   * Initializes {@link Options.OptionInfo.enum_jdoc} for the given
   * <code>OptionInfo</code>.
   */
  private void processEnumJavadoc(Options.OptionInfo oi) {
    Enum<?>[] constants = (Enum<?>[]) oi.base_type.getEnumConstants();
    if (constants == null)
      return;

    oi.enum_jdoc = new LinkedHashMap<String, String>();

    for (Enum<?> constant : constants) {
      assert oi.enum_jdoc != null : "@SuppressWarnings(nullness): bug in flow?";
      oi.enum_jdoc.put(constant.name(), "");
    }

    ClassDoc enum_doc = root.classNamed(oi.base_type.getName());
    if (enum_doc == null)
      return;

    for (String name : oi.enum_jdoc.keySet()) {
      for (FieldDoc fd : enum_doc.fields()) {
        if (fd.name().equals(name)) {
          if (formatJavadoc) {
            oi.enum_jdoc.put(name, fd.commentText());
          } else {
            oi.enum_jdoc.put(name, javadocToHtml(fd));
          }
          break;
        }
      }
    }
  }

  /**
   * Get the HTML documentation for the underlying options instance.
   */
  public String optionsToHtml() {
    StringBuilderDelimited b = new StringBuilderDelimited(eol);

    if (includeClassDoc) {
      b.append(OptionsDoclet.javadocToHtml(root.classes()[0]));
      b.append("<p>Command line options: </p>");
    }

    b.append("<ul>");
    if (!options.isUsingGroups()) {
      b.append(optionListToHtml(options.getOptions(), 2));
    } else {
      for (Options.OptionGroupInfo gi : options.getOptionGroups()) {
        // Do not include groups without publicized options in output
        if (!gi.any_publicized())
          continue;

        b.append("  <li>" + gi.name);
        b.append("    <ul>");
        b.append(optionListToHtml(gi.optionList, 6));
        b.append("    </ul>");
        b.append("  </li>");
      }
    }
    b.append("</ul>");

    for (Options.OptionInfo oi : options.getOptions()) {
      if (oi.list != null && !oi.unpublicized) {
        b.append(list_help);
        break;
      }
    }

    return b.toString();
  }

  /**
   * Get the HTML documentation for the underlying options instance, formatted
   * as a Javadoc comment.
   */
  public String optionsToJavadoc(int padding) {
    StringBuilderDelimited b = new StringBuilderDelimited(eol);
    Scanner s = new Scanner(optionsToHtml());

    while (s.hasNextLine()) {
      StringBuilder bb = new StringBuilder();
      bb.append(StringUtils.repeat(" ", padding)).append("* ").append(s.nextLine());
      b.append(bb);
    }

    return b.toString();
  }

  /**
   * Get the HTML describing many options, formatted as an HTML list.
   */
  private String optionListToHtml(List<Options.OptionInfo> opt_list, int padding) {
    StringBuilderDelimited b = new StringBuilderDelimited(eol);
    for (Options.OptionInfo oi : opt_list) {
      if (oi.unpublicized)
        continue;
      StringBuilder bb = new StringBuilder();
      String optHtml = optionToHtml(oi);
      bb.append(StringUtils.repeat(" ", padding));
      bb.append("<li>").append(optHtml).append("</li>");
      b.append(bb);
    }
    return b.toString();
  }

  /**
   * Get the line of HTML describing an Option.
   */
  public String optionToHtml(Options.OptionInfo oi) {
    StringBuilder b = new StringBuilder();
    Formatter f = new Formatter(b);
    if (oi.short_name != null)
      f.format("<b>-%s</b> ", oi.short_name);
    for (String a : oi.aliases)
      f.format("<b>%s</b> ", a);
    String prefix = getUseSingleDash() ? "-" : "--";
    f.format("<b>%s%s=</b><i>%s</i>", prefix, oi.long_name, oi.type_name);
    if (oi.list != null)
      b.append(" <tt>[+]</tt>");
    b.append(". ");
    String jdoc = oi.jdoc == null ? "" : oi.jdoc; // FIXME: suppress nullness warnings
    if (oi.no_doc_default || oi.default_str == null) {
      f.format("%s", jdoc);
    } else {
      String default_str = "default " + oi.default_str;
      // The default string must be HTML escaped since it comes from a string
      // rather than a Javadoc comment.
      f.format("%s [%s]", jdoc, StringEscapeUtils.escapeHtml(default_str));
    }
    if (oi.base_type.isEnum()) {
      b.append("<ul>");
      assert oi.enum_jdoc != null : "@SuppressWarnings(nullness): dependent: non-null if oi.base_type is an enum";
      for (Map.Entry<String, String> entry : oi.enum_jdoc.entrySet()) {
        b.append("<li><b>").append(entry.getKey()).append("</b>");
        if (entry.getValue().length() != 0)
          b.append(" ").append(entry.getValue());
        b.append("</li>");
      }
      b.append("</ul>");
    }
    return b.toString();
  }

  /**
   * Replace the @link tags and block @see tags in a Javadoc comment with
   * sensible, non-hyperlinked HTML.  This keeps most of the information in the
   * comment while still being presentable. <p>
   * 
   * This is only a temporary solution.  Ideally, @link/@see tags would be
   * converted to HTML links which point to actual documentation.
   */
  public static String javadocToHtml(Doc doc) {
    StringBuilder b = new StringBuilder();
    Tag[] tags = doc.inlineTags();
    for (Tag tag : tags) {
      if (tag instanceof SeeTag)
        b.append("<code>" + tag.text() + "</code>");
      else
        b.append(tag.text());
    }
    SeeTag[] seetags = doc.seeTags();
    if (seetags.length > 0) {
      b.append(" See: ");
      StringBuilderDelimited bb = new StringBuilderDelimited(", ");
      for (SeeTag tag : seetags)
        bb.append("<code>" + tag.text() + "</code>");
      b.append(bb);
      b.append(".");
    }
    return b.toString();
  }

  // Getters and Setters

  public boolean getFormatJavadoc() {
    return formatJavadoc;
  }

  public void setFormatJavadoc(boolean val) {
    if (val && !formatJavadoc) {
      startDelim = "* " + startDelim;
      endDelim = "* " + endDelim;
    } else if (!val && formatJavadoc) {
      startDelim = StringUtils.removeStart("* ", startDelim);
      endDelim = StringUtils.removeStart("* ", endDelim);
    }
    this.formatJavadoc = val;
  }

  public boolean getUseSingleDash() {
    return options.isUsingSingleDash();
  }

  public void setUseSingleDash(boolean val) {
    options.use_single_dash(true);
  }
}
